Skip to content

BridgeJS: Emit static methods and properties on namespaced class entries#717

Merged
krodak merged 5 commits intoswiftwasm:mainfrom
PassiveLogic:kr/fix-class-static-method-ts
Apr 9, 2026
Merged

BridgeJS: Emit static methods and properties on namespaced class entries#717
krodak merged 5 commits intoswiftwasm:mainfrom
PassiveLogic:kr/fix-class-static-method-ts

Conversation

@krodak
Copy link
Copy Markdown
Member

@krodak krodak commented Apr 9, 2026

Overview

@JS(namespace: …) classes silently drop their @JS static func and @JS static var declarations from the generated bridge-js.d.ts. The JS runtime is unaffected (the class definition still has the static members and the namespace tree references it by symbol), but TypeScript consumers see an incomplete type and cannot call static factories through typeof MyNamespace.MyClass without a hand-written augmentation.

// Before: static members are missing from the namespace entry
__Swift: {
    Foundation: {
        Greeter: {
            new(name: string): Greeter;
        }
    },
},

// After
__Swift: {
    Foundation: {
        Greeter: {
            new(name: string): Greeter;
            makeDefault(): Greeter;
            readonly defaultGreeting: string;
        }
    },
},

Root cause

BridgeJSLink.generateTypeScript writes the export type Exports block using two different paths for classes:

  1. Non-namespaced classes (renderExportedClass in BridgeJSLink.swift): builds dtsExportEntry by iterating klass.methods.filter(\.effects.isStatic) and the static subset of klass.properties. The produced entry is appended to data.dtsExportLines and includes constructor + static methods + static properties.
  2. Namespaced classes (inline renderClassEntry closure inside generateTypeScript, passed to NamespaceBuilder.buildHierarchicalExportsType): only emitted the constructor. Static methods and static properties were completely ignored.

Because renderExportedClass is still called for every class (to produce the JS class body), the JS side has static makeDefault() etc. as expected, and the createExports namespace tree references the class symbol by name. That's why runtime calls to exports.__Swift.Foundation.Greeter.makeDefault() work even with a missing static declaration in .d.ts.

Fix

Mirror the non-namespaced path inside the renderClassEntry closure so namespaced classes emit their statics alongside new(...):

for method in klass.methods where method.effects.isStatic {
    printer.write(
        "\(method.name)\(self.renderTSSignature(parameters: method.parameters, returnType: method.returnType, effects: method.effects));"
    )
}
for property in klass.properties where property.isStatic {
    let readonly = property.isReadonly ? "readonly " : ""
    printer.write("\(readonly)\(property.name): \(property.type.tsType);")
}

13 lines added to BridgeJSLink.swift, no other source changes.

Regression coverage

Snapshot tests (Plugins/BridgeJS/Tests/):
Namespaces.swift already had two namespaced classes but none exercised static methods or properties. Extended __Swift.Foundation.Greeter with a static factory and a static readonly property. The Namespaces.swift and Namespaces.Global.swift snapshot sets now cover the fixed output across both the codegen and the linker test suites.

E2E test (Tests/BridgeJSRuntimeTests/, Tests/prelude.mjs):
Extended the existing @JS(namespace: "__Swift.Foundation") class UUID with @JS static func fromValue(_:) -> UUID and @JS static var placeholder: String, then added assertions in prelude.mjs that call both via exports.__Swift.Foundation.UUID.fromValue(...) and exports.__Swift.Foundation.UUID.placeholder. This proves the generated JavaScript routes calls to the correct static thunks at runtime, not just that the .d.ts types are well-formed.

Follow-up: path consolidation

With the fix in place, the inline renderClassEntry closure became a duplicate of what renderExportedClass already returns as dtsExportEntry. A follow-up commit replaces the closure with a direct renderExportedClass call, discarding the JS and instance-interface outputs and keeping only dtsExportEntry. This makes both paths share a single source of truth and threads throws through buildHierarchicalExportsType, populateTypeScriptExportLines, and generateTypeScript.

Verification

  • swift test --package-path ./Plugins/BridgeJS --disable-experimental-prebuilts — 105/105 pass on the default swift-syntax (600.0.1).
  • BRIDGEJS_OVERRIDE_SWIFT_SYNTAX_VERSION=602.0.0 swift test --package-path ./Plugins/BridgeJS --disable-experimental-prebuilts — 106/106 pass.
  • ./Utilities/format.swift — no additional diffs.
  • ./Utilities/bridge-js-generate.sh — AoT bindings updated for the new UUID static method and property added to the e2e test.

The TypeScript `.d.ts` namespace-entry builder for `@JS(namespace:)`
classes only emitted the constructor, silently dropping any
`@JS static func` or `@JS static var` declared on the class. The
equivalent path for non-namespaced classes
(`renderExportedClass` → `dtsExportEntryPrinter`) iterates
`klass.methods.filter(\.effects.isStatic)` and the static subset of
`klass.properties`, so the output mismatched between the two paths.

The generated JS class still carries the static members via
`declarationPrefixKeyword: "static"` in `renderExportedClass`, and the
namespace tree references it by symbol, so the JavaScript runtime
works. TypeScript consumers, however, see an incomplete type and cannot
call the static factory through `typeof MyNamespace.MyClass` without
a hand-written augmentation.

Mirror the non-namespaced path inside the `renderClassEntry` closure in
`generateTypeScript` so namespaced classes emit their static methods
and static properties alongside `new(...)`.

Extended `Namespaces.swift` to exercise the codepath by adding a
static factory and a static readonly property on the existing
`__Swift.Foundation.Greeter` class. The Namespaces snapshot set
captures the fixed output.
@krodak krodak self-assigned this Apr 9, 2026
@krodak krodak marked this pull request as draft April 9, 2026 12:05
krodak added 3 commits April 9, 2026 14:07
…endering

The `renderClassEntry` closure passed to
`buildHierarchicalExportsType` manually rebuilt the same
`ClassName: { new(...); staticMethod(); ... }` block that
`renderExportedClass` already produces as its `dtsExportEntry` return
value. The two paths had to be kept in sync by hand.

Replace the inline closure with a direct call to `renderExportedClass`
that discards the JS and DTS-type outputs and returns only the
`dtsExportEntry` slice. This makes the class namespace entry for
namespaced and non-namespaced classes identical by construction.

Thread `throws` through `buildHierarchicalExportsType`,
`populateTypeScriptExportLines`, and `generateTypeScript` to
accommodate the fact that `renderExportedClass` is throwing. Hoist the
`buildHierarchicalExportsType` call out of the `printer.indent` closure
so the `try` expression is in a throwing context.
…lass

Extends the existing `__Swift.Foundation.UUID` class (which already
has an `@JS init` and an `@JS func`) with a static factory
`fromValue(_:)` and a static readonly property `placeholder`, then
asserts both are reachable via `exports.__Swift.Foundation.UUID.fromValue`
and `exports.__Swift.Foundation.UUID.placeholder` in `prelude.mjs`.

This is the e2e counterpart to the snapshot regression added in the
previous commit — it proves the generated JavaScript actually routes
calls to the correct static thunks at runtime, not just that the
`.d.ts` types are well-formed.
`renderExportedClass` was being called twice for every namespaced class:
once in `collectLinkData` (to produce the JS body and instance
interface) and once more inside the `renderClassEntry` closure (to
produce the export-entry for `buildHierarchicalExportsType`), with the
JS and instance-interface outputs discarded on the second call.

Store the `dtsExportEntry` slice in `LinkData.namespacedClassDtsExportEntries`
during the single `collectLinkData` pass, keyed by class name. The
`renderClassEntry` closure is now a non-throwing dictionary lookup, so
`buildHierarchicalExportsType`, `populateTypeScriptExportLines`, and
`generateTypeScript` revert to non-throwing.
@krodak krodak marked this pull request as ready for review April 9, 2026 13:15
@krodak krodak requested a review from kateinoigakukun April 9, 2026 13:15
Static properties on `@JS(namespace:)` classes used
`property.callName()` to build the Swift call expression. That method
consults `property.staticContext`, which is stored as
`.className(abiName)` (e.g. `__Swift_Foundation_UUID`), and emits
`\(abiName).\(propertyName)`. The ABI-mangled name is not a valid Swift
identifier so the generated thunk failed to compile.

Static methods were unaffected because `renderSingleExportedMethod`
passes `klass.swiftCallName` directly.

Fix `PropertyRenderingContext.callName(for:)` for the `.classStatic`
case to bypass `property.callName()` and build the expression directly
from `klass.swiftCallName`. This preserves the ABI symbol name
(which still comes through `context.className` → `klass.abiName` →
`property.getterAbiName(className:)`) while producing a valid Swift
call expression in the thunk body.

Also remove the now-unnecessary comment from `prelude.mjs` that
described what the test was checking (the assertion itself is
self-explanatory).
@krodak krodak merged commit 46182e2 into swiftwasm:main Apr 9, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants